跳到主要内容

文本和字节序列

本章将要讨论 Unicode 字符串、二进制序列,以及在二者之间转换时使用的编码

字符问题

字符串即字符的序列,重点是“字符”如何定义。

对于 Python,“字符” 即 Unicode 字符。字符的相关操作涉及到两个问题:

  • 字符的标识,即码位,是特定字符在字符集中的唯一标识。
    • 码位是 01 114 111 的数字(十进制),在 Unicode 标准中以 46 个十六进制数字表示,而且加前缀“U+”。例如,字母 A 的码位是 U+0041,欧元符号的码位是 U+20AC,高音谱号的码位是 U+1D11E。
  • 字符的字节表述,是字符的具体表述,通过特定的编码算法连接字符和对应的字节表述。
    • 在 UTF-8 编码中,A(U+0041)的码位编码成单个字节 \x41,而在 UTF-16LE 编码中编码成两个字节 \x41\x00。再举个例子,欧元符号(U+20AC)在 UTF-8 编码中是三个字节——\xe2\x82\xac,而在 UTF-16LE 中编码成两个字节:\xac\x20

编码:将码位转换为字节序列的过程

解码:将字节序列转换为码位的过程

Python 对字符和字节进行了完全的区分,具体来说,所有的字节序列的字面量均会以特定标识开头。下述为编 / 解码的一个示例

>>> s = 'café'
>>> len(s)
4
>>> b = s.encode('utf8')
>>> b
b'caf\xc3\xa9'
>>> len(b)
5
>>> b.decode('utf8')
'café

Python 3 的 str 类型基本相当于 Python 2 的 unicode 类型

Python 3 的 bytes 类型基本相当于 Python2 的 str 类型

字符串比较关键的问题是字符的编码算法以及文本文件的处理。前者涉及到的问题众多,而且相关的乱码问题时不时会出现,例如 matplotlib 的中文支持问题,后者则在读写文件时时常会遇到。

字节概要

bytes 是一种不可变数据类型,其元素是 0~255 之间的整数,并且 bytes 类型数据的切片依然是 bytes 类型。

bytearray 类型和 bytes 类型密切相关,bytearray 没有自己的字面量语法,但是和 bytes 类型的行为一致,其切片依然是 bytearray 类型。

>>> cafe = bytes('café', encoding='utf_8') 
>>> cafe
b'caf\xc3\xa9'
>>> cafe[0]
99
>>> cafe[:1]
b'c'
>>> cafe_arr = bytearray(cafe)
>>> cafe_arr
bytearray(b'caf\xc3\xa9')
>>> cafe_arr[-1:]
bytearray(b'\xa9')

my_bytes[0] 获取的是一个整数,而 my_bytes[:1] 返回的是一个长度为 1 的 bytes 对象——这一点应该不会让人意外。s[0] == s[:1] 只对 str 这个序列类型成立。

不过,str 类型的这个行为十分罕见。对其他各个序列类型来说,s[i] 返回一个元素,而 s[i:i+1] 返回一个相同类型的序列,里面是 s[i] 元素。

字节的字面量表示

虽然二进制序列其实是整数序列,但是它们的字面量表示法表明其中有 ASCII 文本。因此,各个字节的值可能会使用下列三种不同的方式显示。

  • 可打印的 ASCII 范围内的字节(从空格到 ~),使用 ASCII 字符本身。
  • 制表符、换行符、回车符和 \ 对应的字节,使用转义序列 \t\n\r\\
  • 其他字节的值,使用十六进制转义序列(例如,\x00 是空字节)。

因此,在上面的代码中 5,我们看到的是 b'caf\xc3\xa9':前 3 个字节 b'caf' 在可打印的 ASCII 范围内,后两个字节则不然。

编解码器

Python 自带了相当数量的编解码器,常用的例如 utf-8、gbk 等

>>> for codec in ['latin_1', 'utf_8', 'utf_16']:
... print(codec, 'El Niño'.encode(codec), sep='\t')
...
latin_1 b'El Ni\xf1o'
utf_8 b'El Ni\xc3\xb1o'
utf_16 b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'

编解码问题会导致一些异常的抛出:

  1. UnicodeEncodeError:str 转 bytes 时
  2. UnicodeDecodeError:bytes 转 str 时
  3. SyntaxError:源码的编码错误

处理文本文件

处理文本的最佳实践是 “Unicode 三明治”

  • 要尽早把输入(例如读取文件时)的字节序列解码成字符串。
  • 这种三明治中的“肉片”是程序的业务逻辑,在这里只能处理字符串对象。在其他处理过程中,一定不能编码或解码。
  • 对输出来说,则要尽量晚地把字符串编码成字节序列。
image-20220808133807453

在 Python 3 中能轻松地采纳 Unicode 三明治的建议,因为内置的 open 函数会在读取文件时做必要的解码,以文本模式写入文件时还会做必要的编码,所以调用 my_file.read() 方法得到的以及传给 my_file.write(text) 方法的都是字符串对象

为了正确比较而规范化 Unicode 字符串

因为 Unicode 有组合字符(变音符号和附加到前一个字符上的记号,打印时作为一个整体),所以字符串比较起来很复杂。

例如,“café” 这个词可以使用两种方式构成,分别有 4 个和 5 个码位,但是结果完全一样:

>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1, s2
('café', 'café')
>>> len(s1), len(s2)
(4, 5)
>>> s1 == s2
False

U+0301 是 COMBINING ACUTE ACCENT,加在“e”后面得到“é”。在 Unicode 标准中,'é''e\u0301' 这样的序列叫“标准等价物”(canonical equivalent),应用程序应该把它们视作相同的字符。但是,Python 看到的是不同的码位序列,因此判定二者不相等。

这个问题的解决方案是使用 unicodedata.normalize 函数提供的 Unicode 规范化。这个函数的第一个参数是这 4 个字符串中的一个:'NFC''NFD''NFKC''NFKD'

  1. NFC:使用最少码位构成等价字符串
  2. NFD:将所有的组合字符解析为基字符和单独的组合字符
  3. NFKC:基本上就是 NFC 的兼容模式,添加了对兼容字符的处理
  4. NFKD:基本上就是 NFD 的兼容模式,添加了对兼容字符的处理
>>> from unicodedata import normalize
>>> s1 = 'café' # 把"e"和重音符组合在一起
>>> s2 = 'cafe\u0301' # 分解成"e"和重音符
>>> len(s1), len(s2)
(4, 5)
>>> len(normalize('NFC', s1)), len(normalize('NFC', s2))
(4, 4)
>>> len(normalize('NFD', s1)), len(normalize('NFD', s2))
(5, 5)
>>> normalize('NFC', s1) == normalize('NFC', s2)
True
>>> normalize('NFD', s1) == normalize('NFD', s2)
True

保存文本之前,最好使用 normalize('NFC', user_text) 清洗字符串。NFC 也是 W3C 的 “Character Model for the World Wide Web: String Matching and Searching” 规范推荐的规范化形式。

使用 NFC 时,有些单字符会被规范成另一个单字符。例如,电阻的单位欧姆(Ω)会被规范成希腊字母大写的欧米加。这两个字符在视觉上是一样的,但是比较时并不相等,因此要规范化,防止出现意外:

>>> from unicodedata import normalize, name
>>> ohm = '\u2126'
>>> name(ohm)
'OHM SIGN'
>>> ohm_c = normalize('NFC', ohm)
>>> name(ohm_c)
'GREEK CAPITAL LETTER OMEGA'
>>> ohm == ohm_c
False
>>> normalize('NFC', ohm) == normalize('NFC', ohm_c)
True

下面是 NFKC 的具体应用:

>>> from unicodedata import normalize, name
>>> half = '½'
>>> normalize('NFKC', half)
'1⁄2'
>>> four_squared = '4²'
>>> normalize('NFKC', four_squared)
'42'
>>> micro = 'μ'
>>> micro_kc = normalize('NFKC', micro)
>>> micro, micro_kc
('μ', 'μ')
>>> ord(micro), ord(micro_kc)
(181, 956)
>>> name(micro), name(micro_kc)
('MICRO SIGN', 'GREEK SMALL LETTER MU')

使用 '1/2' 替代 '½' 可以接受,微符号也确实是小写的希腊字母 'µ',但是把 '4²' 转换成 '42' 就改变原意了。某些应用程序可以把 '4²' 保存为 '4<sup>2</sup>',但是 normalize 函数对格式一无所知。

因此,NFKC 或 NFKD 可能会损失或曲解信息,但是可以使用 NFKC 和 NFKD 规范化形式时要小心,而且只能在特殊情况中使用,例如搜索和索引,而不能用于持久存储,因为这两种转换会导致数据损失。:用户搜索 '1 / 2 inch' 时,如果还能找到包含 '½ inch' 的文档,那么用户会感到满意。

使用 NFKC 和 NFKD 规范化形式时要小心,而且只能在特殊情况中使用,例如搜索和索引,而不能用于持久存储,因为这两种转换会导致数据损失。

大小写折叠

除了组合字符以及兼容字符外,字母的大小写也是一些语言中非常重要的处理对象。对此,显然最常见的方法是 str.lower(),此外,还有 str.casefold(),即大小写折叠。

这两个函数对绝大多数字符来说是等价的,但是对于极少数的特殊字符会得到不同的结果(自 Python3.4 起,有 116 个字符的处理结果不一致)

这是一个规范化字符串匹配函数的示例:

"""
Utility functions for normalized Unicode string comparison.

Using Normal Form C, case sensitive:

>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1 == s2
False
>>> nfc_equal(s1, s2)
True
>>> nfc_equal('A', 'a')
False

Using Normal Form C with case folding:

>>> s3 = 'Straße'
>>> s4 = 'strasse'
>>> s3 == s4
False
>>> nfc_equal(s3, s4)
False
>>> fold_equal(s3, s4)
True
>>> fold_equal(s1, s2)
True
>>> fold_equal('A', 'a')
True

"""

from unicodedata import normalize

def nfc_equal(str1, str2):
return normalize('NFC', str1) == normalize('NFC', str2)

def fold_equal(str1, str2):
return (normalize('NFC', str1).casefold() ==
normalize('NFC', str2).casefold())

除了 Unicode 规范化和大小写折叠(二者都是 Unicode 标准的一部分)之外,有时需要进行更为深入的转换,例如把 'café' 变成 'cafe'。下一节说明何时以及如何进行这种转换。

极端“规范化”:去掉变音符号

去掉变音符号不是正确的规范化方式,因为这往往会改变词的意思,而且可能误判搜索结果,但是在搜索领域往往需要这么做。

除了搜索,去掉变音符号还能让 URL 更易于阅读,至少对拉丁语系语言是如此。下面是维基百科中介绍圣保罗市(São Paulo)的文章的 URL:

http://en.wikipedia.org/wiki/S%C3%A3o_Paulo

其中,“%C3%A3” 是 UTF-8 编码 “ã” 字母(带有波形符的 “a”)转义后得到的结果。下述形式更友好,尽管拼写是错误的:

http://en.wikipedia.org/wiki/Sao_Paulo

这里提供一个去除变音符号的算法。具体而言,首先将所有的组合字符解析为基字符和组合字符(NFD 格式),然后滤除所有的组合字符,最后进行重组即可(NFC 格式)

import unicodedata
import string


def shave_marks(txt):
"""去掉全部变音符号"""
norm_txt = unicodedata.normalize('NFD', txt)
shaved = ''.join(c for c in norm_txt
if not unicodedata.combining(c))
return unicodedata.normalize('NFC', shaved)

----------------------------------------------------------------------------
>>> order = '“Herr Voß: • ½ cup of OEtker™ caffè latte • bowl of açaí.”'
>>> shave_marks(order)
'“Herr Voß: • ½ cup of OEtker™ caffe latte • bowl of acai.”'
>>> Greek = 'Zέφupoς, Zéfiro'
>>> shave_marks(Greek)
'Ζεφupoς, Zefiro'

更进一步地,只把拉丁基字符中所有的变音符号删除

def shave_marks_latin(txt):
"""把拉丁基字符中所有的变音符号删除"""
norm_txt = unicodedata.normalize('NFD', txt)
latin_base = False
keepers = []
for c in norm_txt:
if unicodedata.combining(c) and latin_base: # 基字符为拉丁字母时,跳过组合记号
continue # 忽略拉丁基字符上的变音符号
keepers.append(c)
# 如果不是组合字符,那就是新的基字符
if not unicodedata.combining(c): # 检测新的基字符,判断是不是拉丁字母
latin_base = c in string.ascii_letters
shaved = ''.join(keepers)
return unicodedata.normalize('NFC', shaved)